Skip to content

fix: sync playback resume position across devices in real-time (#91) - #130

Merged
ProdigyV21 merged 1 commit into
mainfrom
fix/realtime-watch-history-sync
Apr 5, 2026
Merged

fix: sync playback resume position across devices in real-time (#91)#130
ProdigyV21 merged 1 commit into
mainfrom
fix/realtime-watch-history-sync

Conversation

@ProdigyV21

Copy link
Copy Markdown
Owner

Summary

Closes #91.

Cross-device playback resume position now updates within ~5 seconds of a progress update on another device, instead of waiting for the receiving device to manually reopen Home or for the 5-minute periodic fallback sync.

Reported behavior

If I stop an episode for example at minute 20 and finish it later on device A, device B is still showing the episode to resume at minute 20 instead of starting the next episode.

Root cause

RealtimeSyncManager only subscribed to one Supabase realtime channel: account_sync_state. But playback progress is written directly to a different table — watch_history — by WatchHistoryRepository.saveProgress() every ~10 seconds during playback. The account_sync_state snapshot is only pushed at pause/stop/end, which meant:

  • ✓ User finished an episode on device A → pushed via account_sync_state on END → device B realtime pull works.
  • ✓ User paused mid-episode on device A → pushed via account_sync_state on PAUSE → works.
  • ✗ User actively watching on device A with no pause: watch_history was being updated on Supabase every 10 seconds, but device B had no realtime signal and only saw the new position after manually reopening Home or after the 5-minute periodic fallback fired.

The bug has been invisible for users who always pause or finish episodes cleanly, but has been breaking cross-device experience for anyone switching devices mid-episode.

Fix

1. Second realtime channel for watch_history

Subscribe to a second channel on the same WebSocket listening for INSERT + UPDATE on watch_history filtered by user_id=eq.$userId:

// Channel 2: watch_history INSERT + UPDATE events for cross-device Continue
// Watching refresh. We subscribe to both INSERT (first watch of a new item)
// and UPDATE (position/progress changes) so either event refreshes the other
// device's Home row.
val watchHistoryJoin = JSONObject().apply {
    put("topic", "realtime:watch_history")
    ...
}
ws.send(watchHistoryJoin.toString())

2. Topic-based dispatch in handleMessage

postgres_changes events now route by topic:

  • realtime:account_syncdebouncedPull() (full cloud snapshot restore — unchanged)
  • realtime:watch_historydebouncedWatchHistoryEmit() (lightweight CW-only refresh — new)

Watch-history events do not trigger cloudSyncRepository.pullFromCloud(). That would be wasteful for a single-row position update — it would re-download the entire addons/profiles/catalogs/IPTV snapshot.

3. watchHistoryEvents: SharedFlow<Unit>

New event stream on RealtimeSyncManager. HomeViewModel collects it in init and calls the existing refreshContinueWatchingOnly(force = true), which re-queries watch_history via WatchHistoryRepository.getContinueWatching() and updates the Continue Watching row inline — no home reload.

4. 5-second debounce to coalesce bursts

Watch history fires every ~10 s during active playback. Without coalescing, a user binge-watching on device A would trigger a Home refresh on every other device every 10 seconds. The new WATCH_HISTORY_DEBOUNCE_MS = 5_000L groups rapid-fire events into a single refresh.

5. Self-echo protection

WatchHistoryRepository.saveProgress() now calls realtimeSyncManager.markLocalWatchHistoryWrite() on every successful save. debouncedWatchHistoryEmit() checks that timestamp with a 3-second window and skips the emit when the event almost certainly came from our own write. Without this, device A would pointlessly refresh its own Continue Watching row every 10 seconds while watching.

private fun debouncedWatchHistoryEmit() {
    if (System.currentTimeMillis() - lastLocalWatchHistoryWriteTimestamp < 3_000L) {
        return  // skip — our own write
    }
    ...
}

6. Periodic fallback: 5 minutes → 90 seconds

PERIODIC_SYNC_INTERVAL_MS was 5 minutes. Lowered to 90 seconds so users on flaky connections (or devices that missed a realtime notification) still see fresh data within a reasonable window. The interval runs pullFromCloud() only if nothing else has fired in between — net cost is at most one small Supabase query per 90 s per active user.

Dependency injection

WatchHistoryRepository now takes Provider<RealtimeSyncManager> (lazy). This matches the existing Provider<AuthRepository> pattern in the same class and avoids any construction-order edge cases. Verified no dependency cycle:

HomeViewModel → RealtimeSyncManager → CloudSyncRepository → (no WatchHistoryRepository)
WatchHistoryRepository → Provider<RealtimeSyncManager>  (lazy, breaks construction)

Test plan

  1. Sign in on two devices (both need ARVIO Cloud auth).
  2. Start playing an episode on device A.
  3. On device B, open Home and note the current resume position for that episode in Continue Watching.
  4. Let device A play for 30+ seconds.
  5. Within ~10 seconds of device A's next watch_history update, device B's Continue Watching row should refresh to show the new position without any manual action.
  6. On device A, pause and watch the next 5-second watch_history update fire. Verify device A's own Continue Watching row does NOT flicker (self-echo skip).
  7. Fully watch an episode to end on device A. Device B should move to the next episode within ~10 seconds.
  8. Kill device B's app, bring it back — Continue Watching should still show the latest state (this path uses the account_sync_state channel, unchanged).

Risk

Medium. Changes touch the realtime sync core. Mitigations:

  • The new realtime:watch_history channel is additive — if it fails, the existing realtime:account_sync channel still works and the 90-second periodic fallback still catches everything.
  • The self-echo guard uses a tight 3-second window so local writes don't suppress legitimate events from other devices that happen to arrive at the same time.
  • The event flow uses extraBufferCapacity = 4 on MutableSharedFlow so event delivery is non-suspending and can't back-pressure the network handler.
  • No schema changes, no new Supabase tables, no migration. Uses the watch_history table that already exists and is already being written to on every device.

Reported behavior: a user finishes or advances an episode on device A,
but device B still shows the old resume position for minutes (until a
manual Home ON_RESUME or the 5-minute periodic fallback sync fires).

Root cause: RealtimeSyncManager only subscribed to the
`account_sync_state` table via a single postgres_changes channel. But
playback progress updates are written directly to the `watch_history`
table by WatchHistoryRepository.saveProgress every ~10 seconds during
playback, NOT through the account_sync_state JSON snapshot. The snapshot
was only pushed at pause/stop/end, which meant:

- A user who finished an episode pushed via account_sync_state on end \u2014 OK.
- A user who paused mid-episode pushed via account_sync_state on pause \u2014 OK.
- A user actively watching on device A with no pause: watch_history
  updated on Supabase every 10 s, but device B had no realtime signal
  and only saw the new position after opening Home or after 5 minutes.

Fix:

1. Subscribe to a SECOND realtime channel `realtime:watch_history` on
   the same WebSocket, listening for INSERT + UPDATE on the shared
   `watch_history` table filtered by `user_id=eq.$userId`. Both events
   trigger a lightweight "refresh Continue Watching" signal. This does
   NOT trigger a full cloud-state pull \u2014 that would be wasteful for a
   single-row position update.

2. Route incoming `postgres_changes` events by their topic so each
   channel dispatches to the right handler:
   - `realtime:account_sync`    \u2192 debouncedPull() (full snapshot)
   - `realtime:watch_history`   \u2192 debouncedWatchHistoryEmit() (CW row only)

3. Expose a `watchHistoryEvents: SharedFlow<Unit>` on the manager.
   HomeViewModel collects this flow in its init block and calls
   refreshContinueWatchingOnly(force = true) on each emission, which
   re-queries `watch_history` via the existing path and updates the
   Continue Watching row inline without reloading the whole home state.

4. Coalesce watch_history bursts with a 5-second debounce. Watch
   history fires every ~10 s during active playback, so without the
   coalescing a user binge-watching on device A would trigger a Home
   refresh on every other device every 10 seconds. The debounce groups
   rapid-fire events into one refresh.

5. Avoid a self-echo loop: WatchHistoryRepository.saveProgress now calls
   realtimeSyncManager.markLocalWatchHistoryWrite() on every successful
   save. debouncedWatchHistoryEmit() checks that timestamp with a 3 s
   window and skips the emit when the event almost certainly came from
   our own write. Without this, device A would pointlessly refresh its
   own Continue Watching row every time it wrote progress.

6. Reduce the periodic fallback sync interval from 5 minutes to 90
   seconds. Users on flaky connections who miss a realtime event will
   still see fresh data within a reasonable time, without hammering
   Supabase. The interval only runs full pullFromCloud() if nothing
   else fired in between, so this is a net-cost of at most one small
   query per 90 s per active user.

Dependency injection: WatchHistoryRepository now takes
Provider<RealtimeSyncManager> (lazy, to break any construction-order
edge cases \u2014 AuthRepository is injected the same way). Verified there
is no dependency cycle: RealtimeSyncManager \u2192 CloudSyncRepository does
not transitively import WatchHistoryRepository.

Closes #91
@ProdigyV21
ProdigyV21 merged commit f91cf1d into main Apr 5, 2026
2 checks passed
@ProdigyV21 ProdigyV21 mentioned this pull request Apr 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cloud sync not working

1 participant